Skip to content

fix: export visible replay completions - #869

Open
zhongkechen wants to merge 5 commits into
mainfrom
codex/fix-854-otel-replay-completions
Open

zhongkechen wants to merge 5 commits into
mainfrom
codex/fix-854-otel-replay-completions

Conversation

@zhongkechen

@zhongkechen zhongkechen commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Issue

Fixes #854.

Description of changes

A callback can complete while an execution is suspended, then be skipped when replay returns its parent's cached result. Both OTel views now retain visible terminal updates from invocation input and checkpoint changes, deliver them through the existing terminal span path, and export unvisited completions before Invocation closes.

Parent registration drains deferred children under the actual replay segment. Completed parent contexts remain available for later updates, and finalization materializes pending ancestors before descendants. Successful observations are deduplicated within an invocation; each newly opened Invocation-view ReplayChildren or virtual-context segment still receives its own natural End.

Design and simplification

  • A private CompletionState owns pending updates, the parent index, in-flight claims, successful-delivery bookkeeping, and cleanup for both views. Completion payloads have one authoritative dictionary; the parent index stores only IDs and preserves observation order. Completed input parents are retained as an ID set.
  • Each plugin keeps its own parent selection, span rendering, and replay-segment policy. State access and parent-registration dequeue use the existing span-registry lock. Tracer and processor callbacks run outside it, and delivery is recorded only after span completion succeeds.
  • Finalization uses standard-library topological ordering. Completed parent references preserve identity but do not count as exported terminal spans. Child processor failures during parent registration are logged without interrupting attachment of the parent's context; undelivered metadata remains pending.
  • Simpler alternatives considered: an invocation-end-only scan cannot attach children while their actual replay parent is active; treating the full snapshot as new work repeats ordinary replay exports; synthesizing core End hooks changes the contract for every plugin. Sharing the delivery state removes duplicated transitions without combining the two views' different span lifecycles.

The parent index tracks only children awaiting delivery. Completing one child removes only its ID; completing the last indexed child removes the parent bucket. Keeping empty buckets until invocation teardown would avoid the pruning branch but retain parent-index entries with no pending child. Pruning preserves this state invariant during the invocation; it does not itself change span delivery. The two-view sibling regression asserts both transitions before finalization can recover a sibling from pending or clear the index.

The root cause is that natural End hooks cannot observe children skipped by replay, active-span registries discard completed parent identities, and operation-level deduplication alone cannot distinguish reopened context segments. This is an internal bug fix using existing snapshots and hooks; it adds no public API or configuration. Core scheduling, status mapping, timestamps, provider flushing, testing-library behavior, and conformance handlers are unchanged. Export recovery and broader finalization failure isolation remain #855.

Validation

Implementation validation (Python 3.13.17, Hatch 1.16.5):

  • Re-ran the public-handler missing-completion regression against the base OTel source at e42947e8: all 12 cases fail because the callback span is absent.
  • hatch run dev-otel:test -q --tb=short: 901 passed.
  • hatch run dev-otel:typecheck: 52 source files clean.
  • hatch fmt --check in the OTel package and git diff --check: passed.
  • Built core and OTel distributions with hatch build.
  • hatch run test-wheel-otel:test -q --tb=short: 901 OTel tests and 12 lifecycle cases passed, with installed wheel hashes and source bytes verified.

Coverage includes input/checkpoint/natural deduplication, terminal statuses, concurrent End delivery, unavailable ancestors, actual replay-parent preference, late updates, context-segment re-entry, cached-parent body skipping, unknown updated IDs, warm reuse, overlapping invocations, and child-processor isolation. Added a two-view regression that completing one child preserves its pending sibling, and strengthened the processor-failure test to assert eventual delivery exactly once within the tested invocation.

Existing behavioral expectations are preserved. Internal-state assertions in test_completion_snapshots.py, test_context_segment_reentry.py, test_terminal_input_parent_refs.py, and e2e/test_completion_snapshot_int.py now follow CompletionState; the input-parent helper test expects IDs in a set instead of complete records in a list. The pre-fix failing run above records the regression evidence.

Parent-index validation (Python 3.13.16, Hatch 1.18.1):

  • hatch run dev-otel:test -q --tb=short: 1,018 passed, including the local e2e suite.
  • hatch run dev-otel:typecheck: 54 source files clean.
  • hatch fmt --check in the OTel package and git diff --check: passed.
  • With empty-parent pruning removed, the original sibling test still passed in both views. The strengthened test fails in both views when pruning is removed, the parent bucket is deleted unconditionally, or the delivered child ID is retained. All temporary mutations were restored before the full suite ran.

test_natural_child_end_preserves_other_pending_children now asserts the remaining sibling payload and index immediately after the first natural End, then ends the second child and asserts both dictionaries are empty. It checks both child spans and their parent before parent/invocation finalization, and retains the final assertions against duplicate delivery. Existing snapshot and public-handler tests continue to cover completions without natural End hooks. This test-only change does not require rebuilding wheels or changing conformance handlers.

Assumptions and limitations

  • Operation parents form the durable service's tree; cyclic input is unsupported.
  • Only visible operation metadata can be exported. An updated ID without its operation cannot reconstruct a completion. Service-shaped public-handler fixtures are covered; deployed-service qualification remains unverified.
  • Unknown parents fall back to Invocation in Invocation view or a derived logical context in Execution view. Available completed-input IDs can supply logical references after actual replay parents have had a chance to register. A reference cannot recreate a missing span.
  • Deduplication is per invocation and does not guarantee exactly-once backend delivery across failed invocations or processor errors. The installed-wheel lane retains the existing testing-dependency selection; that boundary remains [CI]: Validate the built testing-library wheel in OTel compatibility lanes #863.

Implementation decisions

  • Use one shared, invocation-owned state class and an ordered ID index, with no additional lock or retry scheduler.
  • Retain non-recording contexts after parent completion.
  • Preserve parent-first finalization and contain ordinary child-export failures during registration.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Retain terminal input and checkpoint updates until their parent registers
or invocation finalization exports them. Preserve actual replay parent
contexts and close each reopened invocation segment independently.

Share completion claims, pending state, and successful-delivery tracking
between both OTel views. Store payloads once, index children by ID, and
retain completed input parents as an ID set.

Validation: 901 OTel tests, type checking, formatting, and installed-wheel
OTel/lifecycle validation. Twelve public-handler regressions fail against
the base implementation because the callback completion is missing.

Fixes #854
Merge main while retaining completion delivery bookkeeping and
completed-parent contexts in both OpenTelemetry views. Use the
normalized terminal timestamp for exception events and span endings.
@zhongkechen
zhongkechen marked this pull request as ready for review November 2, 2026 22:30
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime November 2, 2026 22:30 — with GitHub Actions Active
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime November 2, 2026 22:31 — with GitHub Actions Active
@github-actions

This comment has been minimized.

@zhongkechen zhongkechen left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static review of the completion-snapshot delivery paths, against head
2b18fa1d. Three comments, all in the new drain code; the first two are the
same missing try/except and are the ones I would block on. The third is a
behaviour-change note rather than a defect.

Checked and cleared, for the record:

  • _completion_parent_span's (A or B) if c else D parses as intended in both
    plugins (the conditional binds looser than or).
  • CompletionState's pending / children_by_parent stay consistent --
    take_children cannot KeyError, since complete() and clear() are the
    only removals from pending and both clean the index under the lock.
  • The repeat=ends_active_context gate is correct: ReplayChildren and virtual
    child contexts both end via on_child_context_end, which always emits
    start_time=None, so ends_active_context is satisfiable and the re-entered
    segment does get its durable status.
  • parent_first only yields keys present in the map, so
    operations[operation_id] is safe.
  • _TERMINAL_STATUSES matches the SDK's _is_terminal_status exactly.
  • Dropping _operation_spans_lock from _start_span is not a span-ID race:
    DeterministicIdGenerator._id_override is a ContextVar, and
    _next_ordered_timestamp takes the lock itself.
  • Leaving the placeholder registered before _start_span is harmless --
    DurableParentSpan.is_recording() is False, so the re-entry guard does not
    end it.
  • Re-export of already-completed operations on a retried invocation is
    pre-existing (the plugin docs note a retry sees the same updated operations),
    as is the end hook firing for a terminal EXECUTION operation. Neither is
    introduced here.

Tests were not run.

Both views drain pending completions as the first step of invocation End.
Neither contains a per-completion failure, so one unexportable completion
takes the Invocation span, the spans a suspended operation left open and
the Workflow span with it.

Two tests, failing on the current code in both views:

- test_failing_processor_at_end_still_closes_the_invocation: a span
  processor that rejects the completion's span and stays failing through
  invocation End, so no retry can succeed. Asserts the completion is lost
  but Invocation and Workflow still close.
- test_partial_start_releases_pending_metadata_at_end already asserted the
  defect as intended behaviour: a start that never resolved an execution
  trace context leaves _fallback_parent with nothing to anchor to, and the
  test expected that ValueError to escape on_invocation_end. It now expects
  the error to be reported and the invocation to finish releasing its scope.
Root cause: _drain_pending_completions runs as the first statement of
_end_invocation_spans in both views and renders every pending completion
uncontained. A raising span processor, or a _fallback_parent with no
execution trace context to anchor to, propagates out of
_end_invocation_spans. on_invocation_end's try/finally still releases the
scope and flushes, but the Invocation span, the spans a suspended
operation left open and the Workflow span are never ended, so the SDK
logs and swallows the hook error and the whole invocation's telemetry is
lost -- including the completions the drain had already rendered.

Contain each completion the way a parent registration drain already does.
A failed render leaves the completion pending and its claim released, so
finalization continues with the next one.

Simpler options considered, per CONTRIBUTING rule 2:

- Move the containment into _render_operation_end so both drains share one
  error boundary and _drain_registered_children loses its try/except.
  Rejected: it would not cover _fallback_parent, which is called by the
  finalization drain before _render_operation_end and is one of the two
  ways this aborts; and it would change the natural on_operation_end path,
  where the SDK is already the error boundary.
- Merge the two drains into one method that always resolves a fallback
  parent. Rejected: on the registration path a parent that is momentarily
  absent is currently deferred and retried, and always resolving a fallback
  would export it under a synthetic parent instead.

parent_first can raise CycleError, which is left uncontained: a durable
operation tree has no parent cycle, so per CONTRIBUTING rule 4 that guard
would have no test that fails without it.
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime November 3, 2026 00:06 — with GitHub Actions Active
@github-actions

This comment has been minimized.

Assert sibling retention and empty-parent pruning before invocation
finalization can mask either broken transition. Verify child spans
before cleanup and preserve the final no-duplicate assertions.
@zhongkechen
zhongkechen deployed to ai-pr-review-runtime November 3, 2026 16:42 — with GitHub Actions Active
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Codex AI review

No actionable findings after reviewing the full diff against CONTRIBUTING rules 1–9. Both prior findings are addressed. Tests were inspected but not executed; deployed-service behavior remains unverified.

Reviewed commit 21f5bc6453891c39bc218ffcd543265fe66552b6. Workflow run

This branch was successfully deployed

1 active deployment
ai-pr-review-runtime — 21f5bc64 Deployed Nov 3, 2026 by zhongkechen via ai-pr-review / Codex review / Generate Codex review #2789
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OTel]: Export unvisited replay completions with the correct parent context

1 participant